atomic是不是线程安全的

什么是线程安全

我们先来看看什么叫做线程安全。

如果你的代码所在的进程中有多个线程在同时运行,而这些线程可能会同时运行这段代码。如果每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全的。
或者说:一个类或者程序所提供的接口对于线程来说是原子操作或者多个线程之间的切换不会导致该接口的执行结果存在二义性,也就是说我们不用考虑同步的问题。

atomic不安全?

OK,接下来我们来看看一段经典的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@property (atomic,copy) NSString *name;

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
@synchronized(self){
for (int i = 0; i<100; i++) {
self.name = @"lemon";
NSLog(@"线程A : %@",self.name);
}
}

});
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
@synchronized(self){
for (int i = 0; i<100; i++) {
self.name = @"well";
NSLog(@"线程B : %@",self.name);
}
}

});

我们定义了一个atomic的name属性,然后我们在两个线程里面分别修改了这个属性的值,按照上面的定义,我们的设想应该是:线程A后面的一定是lemon,线程B后面的一定是well,但是结果是线程A后面跟着的是lemon或者well,线程B打印的是lemon或者well。也就是说取值是不确定的,基于上面的结果,大多数人认为atomic是不安全的。But这不是这篇文章的结论。

atomic 是安全的

我们来关注一下atomic,atomic是原子属性,什么是原子属性呢?

狭义上的原子操作表示一条不可打断的操作,也就是说线程在执行操作过程中,不会被操作系统挂起,而是一定会执行完(理论上拥有CPU时间片无限长)。在单处理器环境下,一条汇编指令显然是原子操作,因为中断也要通过指令来实现,但一句高级语言的代码却不是原子的,因为它最终是由多条汇编语言完成,CPU在进行时间片切换时,大多都会在某条代码的执行过程中。但在多核处理器下,则需要硬件支持

objc4-723中我们可以看到atomic的实现,利用了TLS(Thread-Local Storage)局部线程存储实现了原子属性,这里用了最重要的两个函数:pthread_setspecific以及pthread_getspecific。也就是说atomic是安全的。

这里就有矛盾了,为什么上面说atomic是线程不安全的,这里又说是安全的,Excuse me?

或许我们换一种说法,atomic是线程安全的,但是不能保证指向的对象是线程安全的。怎么解释这句话呢?

也就是大家常说的,atomic只对getter和setter加锁,而没有办法保证对象的数据完整性。

我们要明白一个概念,当我们访问name的时候,其实这里我们访问的有可能是name本身,也有可能是name指向的内存地址。
比如 self.name = @"lemon"是在访问指针本身,这个是受atomic保护的。
[self.name rangOfString:@"lemon"]是在访问name指向的字符串所在的内存区域,atomic无法保证访问指向的内容的读写安全。这是不一样的概念。

我们来看一下以下的示例来说明:

1
2
3
4
5
@property (nonatomic,strong) NSMutableString *name;
//注意这里用的是NSMutableString

self.name = [NSMutableString string]; //ThreadA
[self.name appendString:@"ddd"];//ThreadB

在上面的示例中,我们线程A对name做了初始化操作,然后我们线程B做了赋值操作。系统为我们分配的地址是0x192838452,然后我们需要将这个地址写到name这里,假设写到一半的时候,B开始读取name并且赋值了,这个时候拿到的可能是一个0x19283000,那这个时候就有问题了,我们拿到的地址根本不是我们想要的,最好的情况这是一个野指针不会造成任何问题,但是如果这一块区域是系统的某一个关键的地方,要是被我们不小心给改了那可能问题就有点大了。

如果在上面的示例中我们将nonatomic改成atomic,那么就能保证将0x192838452这个值写到name里面的时候不会有另外一条线程来读取它,也就能保证保证读取出来的值是正确的,而不会是其他异常的值。

所以atomic的工作就是保护name这个指针的读写操作不会同时进行。至于name指向的内容,则不在atomic保护的范围之内。所以我们其实是有点误解atomic,以为它是可以保护name指向的内存地址的内容完整性。其实这并不是它的工作

线程安全如何实现?

这里我们首先要说两个概念:

第一个是上面说的原子性
第二个是CPU时间片轮转算法

现代操作系统在管理普通线程时,通常采用时间片轮转算法(Round Robin,简称 RR)。每个线程会被分配一段时间片(quantum),通常在 10-100 毫秒左右。当线程用完属于自己的时间片以后,就会被操作系统挂起,放入等待队列中,直到下一次被分配时间片,如果线程在时间片结束前阻塞或结束,则CPU当即进行切换。由于线程切换需要时间,如果时间片太短,会导致大量CPU时间浪费在切换上;而如果这个时间片如果太长,会使得其它线程等待太久。

然后我们要明确一下,哪几种情况是线程安全的:

  1. 如果线程是串行访问内存,那么是线程安全的。
  2. 如果操作是原子性的,那么也是线程安全的。

所以在多线程的情况下,我们只要做到原子性,那么就可以保证线程安全。那么我们如何做到原子性呢?

  1. 64位系统的地址总线对于读写指令可以支持8个字节的长度,所以我们常见的char,int,long 这些比较小的数据类型,一定是原子性的,所以这些数据类型一定不会有两个线程同时读写操作。
  2. 如果是常见的对象类型,那么就需要在上层加锁来处理了,原子性其实也是可以根据对象来分为大粒度和小粒度的,也就是说我们可以通过锁把一系列的操作先执行完成,然后再释放锁。

在最开始的例子中,我们可以通过@synchronized来实现。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
@synchronized(self){
for (int i = 0; i<100; i++) {
self.name = @"lemon";
NSLog(@"线程A : %@",self.name);
}
}

});
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
@synchronized(self){
for (int i = 0; i<100; i++) {
self.name = @"well";
NSLog(@"线程B : %@",self.name);
}
}

});

不过根据不再安全的osspinlock一文中,说到,使用@synchronized的性能是最低的,所以我们获取可以考虑使用
dispatch_semaphore_t来处理。

结论

atomic是线程安全的,但是它不能保证指针指向的地址也是线程安全的。在我们一开始写的例子中,它其实是不能决定哪条线程先执行,但是它已经保证了结果一定是well或者lemon,而不是其他的字符,这就是它起的作用。至于要保证上层读写统一的话,那就是需要用锁来解决了。

最后,国庆快乐!!!

-------评论系统采用disqus,如果看不到需要翻墙-------------